查看原文
其他

深入理解分布式共识算法 Paxos

ImportNew 2022-09-23

The following article is from 码上观世界 Author 咬定青松

(给ImportNew加星标,提高Java技能)

作者:咬定青松

Paxos算法是Lamport于1998年在《The Part-Time Parliament》论文中首次公开提出的一种基于消息传递的分布式一致性算法,是目前公认的解决分布式一致性问题最有效的算法之一。

自Paxos问世以来就持续垄断了分布式一致性算法,Paxos这个名词几乎等同于分布式一致性。Google的很多大型分布式系统都采用了Paxos算法来解决分布式一致性问题,如Chubby、Megastore以及Spanner等。

开源的ZooKeeper,以及MySQL 5.7推出的用来取代传统的主从复制的MySQL Group Replication等纷纷采用Paxos算法解决分布式一致性问题。但是Paxos不仅难以理解,更难以实现,又由于该算法的重要性重要到让人难以忽略,因此本文试图从一致性的基础理论出发探讨Paxos的理论和实现。在进行下文前先PO一张其个人生活照,预祝行文顺利!


一致性模型


在计算机的物理世界中,每个系统的进程都是有距离的,比如一个没有在CPU本地缓存的值距离内存条的距离通常为30cm,不考虑CPU装载值等操作开销,仅仅按照信号传输来算,也需要1纳秒(10的负9次方秒)的时间(光信号速度为30万公里每秒)。


如果把本地取值扩展到跨数千公里范围外的计算机,这个时间达到数百毫秒。这就是说计算机物理世界的操作都是耗时的或者说操作是有延时的,因此可以说,客观世界的约束性才是导致上层应用复杂性的根源。而产生这种约束性的直接原因在于1945年美籍匈牙利人冯-诺依曼提出的经典冯-诺依曼计算机存储结构:该结构将指令数据和存储数据公用一个存储器,在控制器的作用下,运算器从存储器中加载指令和数据完成计算之后数据写回存储器,运算器和控制器组成中央处理器(CPU)。


如果输入和输出数据不在存储器中,还需要从外部输入设备读取数据和写出到外部输出设备,输入设备和输出设备组成I/O设备,这样一个微型的计算机结构就成型了,如下图所示:


  


然而这种结构有个很大的弊端:CPU的处理频率远高于存储器,另外运算器的容量也远小于主存储器,因此大量的数据存取和运算就导致了系统瓶颈。为了解决存储器和CPU运算速度不匹配的问题,其中一种解决办法是利用数据的局部性原理,引入CPU高速缓存(L1 cache),在此基础上,还有二级甚至三级缓存。于是导致了新的问题:当存储器中的数据更新,如果无法及时通知缓存失效,就会导致存储器的数据跟缓存数据不一致。


可以说正是因为这种存储和计算分离的结构问题导致了数据不一致问题。这个是一致性问题产生的微观原因,如果将这种结构逐步向上层推进,会发现计算机领域的很多系统设计也都存在这种问题,比如线程中的局部存储和主存中的数据,数据库或者其他外存设备的数据与内存中缓存的数据,分布式系统中的计算与存储分离架构如AWS的计算集群和外部存储系统S3都会存在数据不一致问题,而且随着层级的上升,不一致问题越来越明显,为解决一致性问题引入的方案越来越复杂。


比如在多线程并发写入和读取一个未加同步限制的共享变量,由于每个线程调度顺序的不确定性,导致对公共变量的读取并不能像在单线程中那么“确定“,对于调用方来说,这个读取是不符合预期,也可以说是不准确的。那如何让系统给出符合预期的准确结果呢?在介绍如何解决一致性问题之前,有必须先介绍下一致性模型:给定一些涉及操作与状态的规则,随着操作的演进,系统将一直遵循这些规则。


一致性模型是所有被允许的操作记录的集合。当我们运行一个程序,经过一系列集合中允许的操作,特定的执行结果总是一致的。如果程序意外地执行了非集合中的操作,我们就称执行记录是非一致的。如果任意可能的执行操作都在这个被允许的操作集合内,那么系统就满足一致性模型。因为对一致性要求的程度不同,实现一致性模型的复杂度和代价也不同。按照一致性要求的严格程度不同,有以下几种类型:


顺序一致性(Sequential consistency)


顺序一致性概念是1979年Lamport 在论文《How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs 》中提出,该概念基于在多处理器环境下 如何就存储器和处理器数据达成顺序一致:假设执行结果与这些处理器以某一串行顺序执行的结果相同,同时每个处理器内部操作的执行看起来又与程序描述的顺序一致。满足该条件的多处理器系统我们就认为是顺序一致的。实际上,处理器可以看做一个进程或者一个线程,甚至是一个分布式系统。顺序一致性概念里面有两个约束:


  • 执行结果与这些处理器以某一串行顺序执行的结果相同 多处理器在并发执行下会产生多种运行顺序的组合,如果能够找到一种合法的执行顺序,其结果跟把多处理器串行执行的结果一样,就可以认为符合顺序一致性模型,也就是可以做到顺序一致性。当然在设计这样的系统还需要其他的保证,比如事件通知等。这里的合法就是第二个约束的内容。


  • 每个处理器内部操作的执行看起来又与程序描述的顺序一致 一个处理器内部的操作顺序按照程序定义的顺序执行。比如在一个线程内部,其操作都是串行的,该约束很容易保证。举一个顺序一致性模型的例子:两个线程P1和P2,两种类型的操作:W(x)对共享变量写操作,R()=>x对共享变量的读操作。因为操作是有时间延迟,所以用一个矩形表示:左边沿表示操作开始,右边沿表示操作结束,沿着时间轴方向排列,但P1的R操作和P2的W操作有时间上的重叠。 


 


从图中可以看到两个线程如果按照W(1)=>W(3)=>W(2)=>R(),就可以保证顺序一致性,因为线程内部的两个操作都跟原来顺序保持相对一致,但是R操作时间上在W(2)后面执行。


线性一致性(Linearizability)


线性一致性(Linearizability),或称原子一致性或严格一致性,指的是程序在执行顺序组合中存在可线性化点P的执行模型,这意味着一个操作将在程序的调用和返回之间的某个点P生效,之后被系统中并发运行的所有其他线程所感知。线性一致性概念是1990年 Maurice Herlihy · Jeannette M Wing 在论文《Linearizability: A Correctness Condition for Concurrent Objects》中提出。为理解该概念,先介绍一致性模型中同样存在的happen-before原则:任意的读写操作事件都分为调用发起和请求响应,比如读操作发起和读操作响应,如果某事件A的发起时刻在某事件B的响应时刻之后,那么就说事件B happen-before 事件A。在线程内部,所有事件都满足happen-before原则,满足此原则的操作,后续的读操作一定能够感知(获取)到写操作的结果。但是在多线程环境下,就不一定满足了,同样那上图来说明:



因为在进程P1内部R和W满足happen-before原则,因此W的写能够被R读取到,但是P1的R和P2的W【W(3)】不一定。我们主要关注进程P1和P2在时间上有重叠的读和写操作:在P1读取过程中,W写的响应还没返回,在写开始和返回的区间内,W写可能已经完成,也可能还在进行中,因此R读取的结果可能是1也可能是3,如果考虑到写操作的可中断性,这个值还可能是其他值,只有当P1的读和W满足上述原则,读取的值才是确定的,如下图:

 


但线性一致性要求如果在这个区间某个点,写操作一旦生效,那么后续的任何读一定能够获取到最新值,这意味着该写操作要么成功要么失败(所有后续读操作仍然读取到原来的值),也就是满足原子性,那么也就符合线性一致性。用直白话说就是,在并发场景下,一个线程对共享变量的操作能立即被其他线程感知到。


由于操作的延迟性,操作在发起和获得响应之间已经发生,因此线性一致性允许在操作被响应前是可以被其他线程访问到,而实现这种一致性要求操作必须是原子的:如果一个操作对于系统的其他部分是不可中断的,那这个操作就可以称之为原子的,线性的,不可分割的,那这个操作就可以称之为具有线性一致性的特性。


比较上述两种一致性模型可知:线性一致性是对顺序一致性的加强,两者都要保证在线程内部操作的相对顺序,但线性一致性暗含着一个全局时钟,所有线程按实际发生的时间顺序执行,而顺序一致性只需要保证相对顺序即可。满足线性一致性一定满足顺序一致性,反正不成立。为加深对两者模型的理解,再看下面的示例: 



简单解释下:a中只要保证单进程的内部执行顺序基础上,R(x)和R(y)分别在W(x,4)和W(y,2)之后就得到期望结果(不论结果正确与否),符合顺序一致性,但是因为从全局时钟来看,R(x)在W(x)之后但是不能读取最新值,所以不符合线性一致性。在b中能读取到最新值,所以符合线性一致性。c因为x和y都不能读取最新值,所以不满足顺序一致性更不谈线性一致性。


因果一致性(Casual consistency)


线性一致性要求所有线程的操作按照一个绝对的时钟顺序执行,这意味着线性一致性是限制并发的,否则这种顺序性就无法保证。由于在真实环境中很难保证绝对时钟同步,因此线性一致性是一种理论,实现线性一致性的代价也最高,但是实战中可以弱化部分线性一致性:只保证有因果关系的事件的顺序,没有因果关系的事件可以并发执行。所谓因果也可以用前文中的happen-before原则来阐述:如果A事件 happen-before B事件,那么事件A,B之间存在因果关系。在分布式系统设计中,经常会因为副本数据同步的延迟导致因果关系颠倒的现象,以下引用一个问答系统中的案例: 



 图中先有问然后才有答,但是因为在副本数据同步的时候,问的数据同步落后于答的数据同步,从而导致在观察者看来,先有答然后才有问。解决因果不一致问题是个大课题,本文不做深入探讨。


一致性小結


本文从开始介绍一致性问题产生根源涉及到系统的不同层级中的一致性,但是如果深究一致性的语义还是略有差别,这里简单归为3类介绍其区别:


  • Coherence 更关注多核共享存储架构的cache 数据一致性;


  • Consistence 更关注分布式系统的一致性,该一致性往往以客户端为视角,比如数据库的事务一致性(ACID),分布式系统副本数据同步的CAP理论中的C都指的是这个一致性。尽管两者还有一定差异:ACID关注的是系统内部的数据一致性,CAP关注的是多副本数据对外界的一致性;

  • Consensus 关注的是达成一致性的手段或者协议,是一种过程,比如通过Paxos达成共识,一致性是共识的目的。


几个C虽有差别,但也不是毫无关系,只是看待问题的角度和层次的差别,理论很多是相通的。本文主要关注在分布式环境下如何达成共识。


一致性与事务


数据库事务有4个基本性质分别是原子性、一致性、隔离性和持久性(即ACID)。


其中一致性指的是数据处于一种有意义的状态,这种状态是语义上的而不是语法上的。最常见的例子是转帐,例如从帐户A转一笔钱到帐户B上,如果帐户A上的钱减少了,而帐户B上的钱却没有增加,那么我们认为此时数据处于不一致的状态。因为数据库操作的复杂性,实现这种一致性必须通过几种途径,原子性便是其中之一,它要求在同一个事务内部的一组操作必须要么全部执行成功要么全部失败,不允许中间状态存在,这就是事务处理的原子性。因为事务执行过程中可能会系统崩溃导致原子性失败,为了解决这种情况,引入REDO和UNDO日志,当数据库系统崩溃后重启,先读取REDO日志重演所有已经执行成功但尚未写入到磁盘的操作,保证持久性,再读取UNDO日志撤销所有到崩溃时尚未成功提交的事务,保证原子性。


但是,原子性并不能完全保证一致性。在多个事务并行进行的情况下,即使保证了每一个事务的原子性,仍然可能导致数据不一致的结果。例如,事务1需要将100元转入帐号A:先读取帐号A的值,然后在这个值上加上100。但是,在这两个操作之间,另一个事务2修改了帐号A的值,为它增加了100元。那么最后的结果应该是A增加了200元。但事实上,事务1最终完成后,帐号A只增加了100元,因为事务2的修改结果被事务1覆盖掉了。为了保证并发情况下的一致性,引入了隔离性,即保证每一个事务能够看到的数据总是一致的,就好象其它并发事务并不存在一样。用术语来说,就是多个事务并发执行后的状态,和它们串行执行后的状态是等价的。


怎样实现隔离性呢?原则上无非是两种类型的锁:一种是悲观锁,即当前事务将所有涉及操作的对象加锁,操作完成后释放给其它对象使用。一种是乐观锁,即不同的事务可以同时看到同一对象(一般是数据行)的不同历史版本。如果有两个事务同时修改了同一数据行,那么在较晚的事务提交时进行冲突检测。可见在数据库事务中,一致性是基础和目标,其他属性都是为一致性服务的。


线性化与串行化 线性化与串行化不同,它不构成事务。因此不能完全保证并发写的安全性。数据库可以同时提供串行化和线性化,如两阶段锁便是可以同时提供串行化与线性化,而序列化的快照隔离不是线性化的。


一致性与分布式理论


在分布式系统领域一直有两个重要的理论用于指导实践,分别是CAP和FLP。


CAP


CAP理论由加州大学伯克利分校的计算机教授Eric Brewer在2000年提出,其核心思想是任何基于网络的数据共享系统最多只能满足数据一致性(Consistency)、可用性(Availability)和网络分区容忍(Partition Tolerance)三个特性中的两个,三个特性的定义如下:


a. 一致性 (Consistency):也就是线性一致性,一个写操作返回成功,那么之后的读请求都必须读到这个新数据;如果返回失败,那么所有读操作都不能读到这个数据。所有节点访问同一份最新的数据。


b. 可用性 (Availability):对数据更新具备高可用性,请求能够及时处理,不会一直等待,即使出现节点失效。


c. 分区容错性 (Partition tolerance):能容忍网络分区,在网络断开的情况下,被分隔的节点仍能正常对外提供服务。


CAP理论的表述很好地服务了它的目的,开阔了分布式系统设计者的思路,在多样化的取舍方案下设计出多样化的系统。在过去的十几年里确实涌现了不计其数的新系统,也随之在一致性和可用性的相对关系上产生了相当多的争论。一般来说使用网络通信的分布式系统,无法舍弃P性质,那么就只能在一致性和可用性上做选择。既然在分布式系统中一致性和可用性只能选一个。


那Paxos、Raft等分布式一致性算法是如何做到在保证一定的可用性的同时,对外提供强一致性呢?在CAP理论提出十二年之后,其作者又出来辟谣。“三选二”的公式一直存在着误导性,它会过分简单化各性质之间的相互关系:首先,由于分区很少发生,那么在系统不存在分区的情况下没什么理由牺牲C或A。其次,C与A之间的取舍可以在同一系统内以非常细小的粒度反复发生,而每一次的决策可能因为具体的操作,乃至因为牵涉到特定的数据或用户而有所不同。最后,这三种性质都可以在程度上衡量,并不是非黑即白的有或无。可用性显然是在0%到100%之间连续变化的,一致性分很多级别,连分区也可以细分为不同含义,如系统内的不同部分对于是否存在分区可以有不一样的认知。所以一致性和可用性并不是水火不容,非此即彼的。Paxos、Raft等分布式一致性算法就是在一致性和可用性之间做到了很好的平衡的见证。


FLP


FLP定理是由 Fischer,Lynch 和 Patterson 三位科学家于 1985 年发表,是分布式理论中最重要的理论之一,它指出在最小化异步网络通信场景下,即使只有一个节点失败,也没有一种确定性的共识算法能够使得在其他节点之间保持数据一致。


a. 统模型假设


  • 异步通信 与同步通信的最大区别是没有时钟、不能时间同步、不能使用超时、不能探测失败、消息可任意延迟、消息可乱序

  • 通信健壮 只要进程非失败,消息虽会被无限延迟,但最终会被送达;并且消息仅会被送达一次(无重复)

  • fail-stop模型 进程失败如同宕机,不再处理任何消息。相对Byzantine模型,不会产生错误消息

  • 失败进程数量 最多一个进程失败


b. 衡量标准 衡量一个分布式算法是否正确有三个标准:


  • Termination(终止性)非失败进程最终可以做出选择

  • Agreement(一致性)所有的进程必须做出相同的决议

  • Validity(合法性)进程的决议值,必须是其他进程提交的请求值 终止性,描述了算法必须在有限时间内结束,不能无限循环下去;一致性描述了我们期望的相同决议;合法性是为了排除进程初始值对自身的干扰。


CAP与FLP关系


FLP讨论的分布式共识(distributed consensus)的问题。分布式共识可实现的功能包括:


  • leader election

  • replicated state machine

  • distributed commit


而CAP关注的是复制存储(replicated storage)的问题,replicated storage可以看作是replicated state machine的一个特例。可以看出,复制存储是分布式共识的子问题。也即,FLP关注的问题更加通用,CAP问题是FLP问题的子集。此外,CAP中的复制存储问题只讨论了这样一类问题:同一份数据在不同节点上进行存储(主从复制即是这样一类问题);而FLP中的分布式共识问题除了CAP中的问题外,还讨论了这样一类问题:多个任务(数据)被调度到不同节点上并行执行(存储),不同节点上的任务和状态可能是不同的(2PC协议即包含了这样一类问题)。由此也可见,FLP中讨论的问题更加复杂。一些方案可能无法解决FLP中的问题,但可能能够解决CAP中的问题。


一致性与两阶段提交


在计算机网络及数据库领域内,为了使分布式数据库的所有节点在进行事务提交时都保持一致性而设计的一种算法。在分布式系统中,每个节点虽然都可以知道自己的操作是否成功,却无法知道其他节点的操作是否成功。在一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果,并最终指示这些节点是否真正提交操作结果(比如将更新后的数据写入磁盘等),这就是两阶段提交。


二阶段提交


两阶段提交协议是协调所有分布式原子事务参与者,并决定提交或取消(回滚)的分布式算法。在两阶段提交协议中,系统一般包含两类机器(或节点):一类为协调者(coordinator),通常一个系统中只有一个;另一类为事务参与者(participants,cohorts或workers),一般包含多个,在数据存储系统中可以理解为数据副本的个数。协议中假设每个节点都会记录写前日志(write-ahead log)并持久性存储,即使节点发生故障日志也不会丢失。协议中同时假设节点不会发生永久性故障而且任意两个节点都可以互相通信。两个阶段为:


  1. 请求阶段(commit-request phase,或称表决阶段,voting phase) 在请求阶段,协调者将通知事务参与者准备提交或取消事务,然后进入表决过程。在表决过程中,参与者将告知协调者自己的决策:同意(事务参与者本地作业执行成功)或取消(本地作业执行故障)。


  2. 提交阶段(commit phase) 在该阶段,协调者将基于第一个阶段的投票结果进行决策:提交或取消。当且仅当所有的参与者同意提交事务协调者才通知所有的参与者提交事务,否则协调者将通知所有的参与者取消事务。参与者在接收到协调者发来的消息后将执行响应的操作。


两阶段提交的缺点


  1. 同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。



  2. 单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)。


  3. 数据不一致。在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。为此,三阶段协议在2PC的准备阶段和提交阶段之间,插入预提交阶段,使3PC拥有CanCommit、PreCommit、DoCommit三个阶段。PreCommit是一个缓冲,保证了在最后提交阶段之前各参与节点的状态是一致的。但是,如果进入PreCommit后,Coordinator发出的是abort请求,假设只有一个Cohort收到并进行了abort操作,而其他对于系统状态未知的Cohort会根据3PC选择继续Commit,此时系统状态发生不一致性。


由上文我们可以了解到虽然分布式事务可以使用多阶段提交协议来实现,但是它也引入了新的问题:比如协调器容易产生单点故障问题而且效率低下,但是它通过协调器在正式提交前先让所有节点达成共识的思路给其他不确定的系统带来了具体的解决方案,实际上只要大多数节点能正常工作,就可以做到对节点容错。


一致性与Paxos


Paxos算法解决的问题正是分布式一致性问题,即一个分布式系统中的各个进程如何就某个值(决议)达成一致。Paxos算法运行在允许宕机故障的异步系统中,不要求可靠的消息传递,可容忍消息丢失、延迟、乱序以及重复。它利用大多数 (Majority) 机制保证了2F+1的容错能力,即2F+1个节点的系统最多允许F个节点同时出现故障。一个或多个提议进程 (Proposer) 可以发起提案 (Proposal),Paxos算法使所有提案中的某一个提案,在所有进程中达成一致。系统中的多数派同时认可该提案,即达成了一致。最多只针对一个确定的提案达成一致。这里的提案可以理解为某个状态值,也可以理解为一条binlog日志,甚至是一条命令(command)。。。根据应用场景不同,提案可以代表不同的含义,Paxos解决的常见问题有:


  • 领导者选举:多个候选节点就谁将是leader达成一致;


  • 互斥:也可以看成分布式锁,进程就谁先进入临界区达成一致;


  • 原子广播:比如分布式存储系统中主备数据复制,多个主节点将写操作的log同步给备节点用于数据回放,为了让备节点与主节点数据完全一致,必须保证log的顺序。即进程需要就消息传递的顺序达成一致。


可见Paxos更关注如何就数据达成一致,不失一般性,假设有一组可以提出(propose)提案(value)的进程集合,如何保证在这些提案中,只有一个被选定(chosen)?要求:


  • 只有被提出的value才能被选定;


  • 只有一个value被选定,并且


  • 如果某个进程认为某个value被选定了,那么这个value必须是真的被选定的那个。


这就是算法的安全性(Safety)要求。为了方便描述,我将系统Paxos角色限定为两种:提议者(Proposer)和接受者(Acceptor),暂时不考虑学习者(Learner)。在一轮分布式选定一个value的共识过程中,可能存在多个提议者和多个接受者,只要这样才能够容忍节点故障和摆脱单点问题。


Paxos算法推导


为了深入理解Paxos工作过程,我们从推导过程着手:Proposers向Acceptors提出提案,为了保证最多只有一个值被选定,但又必须接受一个值,所以有Paxos算法第一个约束:

P1:Acceptor必须接受(Accept)它所收到的第一个Proposal。


如何实现呢?可以给每个提案一个编号,编号可以通过全局的序号生成器,或者绝对时钟的时间戳,Acceptor接受最小的序号的提案,但这样产生一个问题,如果多个Proposers同时提出Proposal,分别发给不同的Acceptor。根据P1,每个Acceptor都接受了不同的value,导致不同的value被选中,无法达成一致。于是规定:

一个提案被选定需要至少超过半数的Acceptor接受。


该规定意味着,每个Acceptor都可以接受多个提案,根据P1,是不是最小序号的提案接受之后,序号更大的编号就可以不接受呢?如果大多数Acceptors是同一组Acceptor的话,确实可以不接受,但问题是Acceptors代表大多数,仍有小部分没有接受。另外两个Acceptor大多数一定有交集,如果后面一个Acceptor大多数接受的value跟前一个Acceptor大多数value不一样,同样导致不一致发生,于是为了保证Safety要求,Paxos进一步提出:

P2:如果值为v的Proposal被选定(Chosen),则任何被选定(Chosen)的具有更高编号的Proposal值也一定为v。


只要算法同时满足P1和P2,就保证了Safety。一个提案只有被接受才可能被选定。于是进一步得到约束:

P2a:如果值为v的Proposal被选定(Chosen),则对所有的Acceptors,它们接受(Accept)的任何具有更高编号的Proposal值也一定为v。


如果满足P2a则一定满足P2,但是P2a依然难以实现,考虑到不同提案最终被选定为同一个value,可以进一步“溯源”,不同提案的value也必须相同:

P2b:如果值为v的Proposal被选定(Chosen),则对所有的Proposer,它们提出的的任何具有更高编号的Proposal值也一定为v。


这几个约束之间的推到关系就表示为:P2b=>P2a=>P2,然后问题就转化为如何保证提案者提出的value都一样呢?有如下定理:

P2c:对于任意的提案序号N和提案V,如果提案[N,V]被提出,那么对于一定存在的包含超过一半Acceptors组成的集合S,满足(1) S中的Acceptors都没有接受(Accept)过编号比N小的Proposal,或者(2) S中的Acceptors所接受过(Accept)的编号最大的Proposal值是V。


通过归纳法既可以证明该结论,为了间接性描述,我们姑且直观地认定该结论:

只要Acceptor坚守只接受更大序号的提案的承诺,亦即只要Acceptor没有响应过序号大于N的提案就可以接受该提案就能满足P2c。满足P2c即满足P2b即满足P2a即满足P2。

如何实现P2c呢?化果为因,每个提案在提出前,先尝试从大部分Acceptor处获取当前接受的最大提案value,然后以value当做提案提交即可。如果Acceptor都没有接受过提案,那就按照自己期望的value直接提交。这就需要一个两阶段过程,前者称为prepare过程,后者才真正去提交,称为Accept过程。至此Paxos提出了Proposer的执行流程,以满足P2c。

Paxos算法流程


  1. Proposer选择一个编号n,向超过一半的Acceptors发送请求消息,Acceptor回复: (a)承诺不会接受编号比n小的proposal,以及 (b)它所接受过的编号比n小的最大Proposal(如果有)。


  2. 如果Proposer收到超过一半Acceptors的回复,它就可以提出Proposal,Proposal的值为收到回复中编号最大的Proposal的值,如果没有这样的值,则可以自由提出任何值。


  3. 向收到回复的Acceptors发送Accept请求,请求对方接受提出的Proposal。两个阶段的Acceptors不一定相同。


伪代码描述如下:

  


Paxos算法实现


考虑到算法研究学习的目的,在单机不太可能表现真实环境中的执行环境,这里通过用线程表示提议者和接受者实体,同时为了模拟消息发送过程中的丢失,每次随机生成一定数量的Acceptor用于发送prepare和accept请求,消息通信使用socket。同时为了简化代码,这里只模拟一次提案的协商。实现过程描述如下:首先定义提案的数据结构,用Proposal 表示:


然后创建提议者Proposer和接受者Acceptor,分别用Thread子类实现。其中在Proposer中提供prepare和accept方法,用于发起prepare请求和accept请求: 

 


Acceptor比较简单,首先使用一个Map存储当前所接受的提案信息:


Map<String, Proposal> acceptedProposals = new HashMap();


然后根据提案所在阶段分别响应,如果是prepare阶段:a. 如果没有接受任何提案,就返回提案为空值的正常响应;b 如果当前提案需要比接受的提案序号大,则更新当前接受的提案的编号为最新,同时返回接受的提案。如果是accept阶段:a. 如果没有接受任何提案,则接受当前提案并返回接受的提案;b 如果当前提案序号不小于接受的提案序号,则更新当前接受的提案的编号为最新,同时返回接受的提案。这里也可以返回接受提案的最大序号。

 


最后初始化提议者和接受者,这里用3个Proposer和5个Acceptor实现如下: 


 一次协商过程,当prepare满足大多数Acceptor的正常响应,才可以进行第二阶段的accept:

 


结语


一致性 问题普遍存在于计算机的各个领域,小到cpu cache,大到数据库事务,分布式系统。因为现实世界的复杂性,导致一致性问题在分布式领域更加突出。Paxos是当前解决分布式一致性问题最有效、理论最完美的算法,同时也是最难实现和理解的算法。但是难者不会,会者不难。


实际上,Paxos算法本身并不复杂,关键在于理解每一步操作背后的原因和建立必备的知识背景。为了建立足够的背景知识,本文介绍了一致性问题的原因和分类。为了介绍一致性问题的解决方法,本文分别描述了单机的数据库事务和分布式的事务的处理方法,其中数据库事务保证了一致性,而分布式事务通过看似原子性的提交方式还不能达到原子性,更不能解决一致性问题。但是通过协商一致性的思路对其他方案有启发作用。解决分布式一致性问题是大问题也是难题,围绕着该问题,有两大理论用于指导实践,虽然无法在异步分布式环境中达到完全的强一致性,但可以通过实际场景进行妥协和部分牺牲,达到解决问题的目的。


Paxos也是在一致性和可用性之间达到平衡,从而成为在分布式领域至关重要的算法,相信有了上述的铺垫,再理解Paxos会好很多,个人觉得理解Paxos的难点在于理解如何确定提案,这涉及到两阶段选定提案的方式,该方式恰恰就是协商一致性的一个运用。


推荐阅读  点击标题可跳转

分享大厂分布式唯一ID设计方案,快来围观

一口气说出 9 种分布式 ID 生成方式,面试官有点懵了

一个基于 RabbitMQ 的可复用的分布式事务消息架构方案!


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存